Linux 共享内存与内存映射
管道每传一次数据都要经过内核中转,共享内存是 Linux 里最快的 IPC 方式:多个进程映射同一块物理内存,读写不经内核搬运。实现它有两条路——System V 共享内存(shmget 一族)和内存映射(mmap)。本文写给需要高频进程间数据交换的开发者,讲清两条路的接口、易错点与选择依据。
两条路的共同原理
普通 IPC(管道、消息队列)需要「发送方从用户空间复制进内核、接收方再从内核复制回用户空间」两次拷贝;共享内存与 mmap(MAP_SHARED) 都把同一物理页装进各进程的地址空间,数据本身不动,天然没有这两次拷贝,这也是两者速度都快的原因。代价是需要自己解决同步(通常配合信号量使用)。
内存映射 mmap
mmap 把磁盘文件映射进内存:返回一个指针,写这块内存等于写文件(MAP_SHARED 模式下自动同步回磁盘)。
c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// addr:传 NULL 由内核选择地址
// length:映射长度,不能为 0,建议取文件实际大小(stat/lseek 获取)
// prot:PROT_READ / PROT_WRITE / PROT_EXEC / PROT_NONE,
// 操作映射内存必须至少含 PROT_READ
// flags:MAP_SHARED(写回文件,IPC 必须用它)或
// MAP_PRIVATE(写时复制,改动不影响原文件)
// fd:open 得到的文件描述符,文件大小不能为 0,
// open 的权限不能与 prot 冲突(见下方易错点)
// offset:偏移量,必须是页大小(通常 4KB)的整数倍,0 表示从头映射
// 返回:成功返回映射区首地址,失败返回 MAP_FAILED((void *) -1)
int munmap(void *addr, size_t length); // 释放映射,addr 必须是 mmap 的返回值 父子进程通信
fork 之前建好映射区,子进程直接继承:
c
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <wait.h>
int main() {
int fd = open("test.txt", O_RDWR); // 目标文件,需非空
int size = lseek(fd, 0, SEEK_END);
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
return 1;
}
pid_t pid = fork();
if (pid > 0) {
wait(NULL);
char buf[64];
strcpy(buf, (char *)ptr); // 父进程读到子进程写入的数据
printf("read data: %s\n", buf);
} else if (pid == 0) {
strcpy((char *)ptr, "hello from child");
}
munmap(ptr, size);
return 0;
} 无关进程通信则各自 open 同一个文件、各自 mmap,效果相同。注意 mmap 通信是非阻塞的:对面还没写入时读到的是映射区的当前内容,同步要靠应用层约定。
七个易错点
- 对返回值
ptr做ptr++之后再munmap(ptr, len)会失败——释放必须传映射时的首地址,原值要留存 - open 用了
O_RDONLY,prot 却要PROT_READ | PROT_WRITE:返回 MAP_FAILED。open 权限应与 prot 一致(prot 要写则 open 必须读写) - offset 不是页大小整数倍:返回 MAP_FAILED(EINVAL)
- 其他失败情形:length 为 0;prot 只给 PROT_WRITE(不给读权限);prot 与 open 权限冲突
- 想映射
O_CREAT新建的空文件:文件大小为 0 不行,先扩展(lseek 到末尾写一个字节,或truncate()) - mmap 之后 close(fd):映射依然有效,close 不影响已建立的映射
- 只映射 100 字节却访问第 101 字节:越界访问非法内存,段错误
System V 共享内存
System V shm 不依赖文件,直接向内核申请一段内存,用 key 标识:
c
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
// 创建或获取一个共享内存段,返回其 id
// key:非 0 标识(常用 ftok 生成);size:段大小(新建时有效,新建内容清零)
// shmflg:权限(如 0664)附加 IPC_CREAT(不存在则创建)、
// IPC_CREAT | IPC_EXCL(已存在则报错,用于探测)
// 常见组合:IPC_CREAT | IPC_EXCL | 0664
void *shmat(int shmid, const void *shmaddr, int shmflg);
// 把段附加到本进程,返回首地址
// shmaddr 传 NULL 由内核选址;shmflg 传 0 读写、SHM_RDONLY 只读
int shmdt(const void *shmaddr); // 分离(进程退出时自动分离)
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// cmd:IPC_STAT 读状态 / IPC_SET 设状态 / IPC_RMID 标记删除
// IPC_RMID 时 buf 传 NULL
key_t ftok(const char *pathname, int proj_id);
// 用一个存在的路径 + 8 位项目号(0-255,习惯传字符如 'a')生成 key 使用流程:shmget 创建/获取 → 两边 shmat 附加 → 读写 → shmdt 分离 → 一方 shmctl(IPC_RMID) 标记删除。
两个关键行为:
- 删除是标记式的。
IPC_RMID之后段并不会立刻消失,要等所有附加进程都分离(shm_nattch降为 0)才真正销毁;标记后ipcs里该段的 key 显示为 0(Linux 的实现行为,内核将其置为 IPC_PRIVATE;规范记载的标志是 shm_perm.mode 中的 SHM_DEST)。因此多次调用 IPC_RMID 是安全的 - 进程退出会自动分离,但不会自动删除段——忘记 IPC_RMID 的段会一直留在内核里,重启前都能用
ipcs看到
查看与清理命令
shell
ipcs -a # 所有 IPC 设施
ipcs -m # 共享内存段
ipcs -q # 消息队列
ipcs -s # 信号量
ipcrm -m shmid # 按 id 删除共享内存段
ipcrm -M shmkey # 按 key 删除共享内存段
ipcrm -q msqid / -Q msgkey / -s semid / -S semkey # 消息队列、信号量同理 两者怎么选
| 维度 | System V 共享内存 | mmap 内存映射 |
|---|---|---|
| 依赖 | 不需要文件(匿名 IPC key 即可) | 需要一个磁盘文件(匿名映射除外) |
| 数据搬运 | 都不经内核拷贝,性能同级 | 同左 |
| 地址空间 | 所有进程附加同一块共享内存 | 每个进程有自己的虚拟映射,指向同一物理页 |
| 进程崩溃 | 段还在,数据可恢复访问 | 映射消失,但数据已在文件里 |
| 整机断电 | 内存数据丢失 | 文件内容还在(已同步的部分) |
| 生命周期 | 显式 IPC_RMID 且全部分离后才销毁 | 进程退出映射即销毁 |
选择依据归纳:需要数据在进程崩溃后仍可访问、或顺便落盘,用 mmap 映射文件;纯粹的高性能临时共享、不想管理文件,用 System V shm(或 POSIX 的 shm_open + mmap,接口更现代)。无论哪条路,同步都要另配信号量或应用层协议。
要点
- 共享内存与 mmap(MAP_SHARED) 都免除了内核中转的两次拷贝,是最快的 IPC;同步需自理
- mmap 五个硬约束:length 非 0、offset 页对齐、open 权限与 prot 一致、文件非空、munmap 用原首地址
- System V shm 的 IPC_RMID 是标记删除,nattch 归 0 才真正销毁;进程退出只分离不删除
- 遗留的段用
ipcs -m查看、ipcrm -m清理 - 数据要落盘或跨崩溃存活选 mmap;纯内存高速共享选 shm